Skip to content

fix(memory): resolve Vitest false positive for expected rejection#3893

Open
JosephDoUrden wants to merge 1 commit intomodelcontextprotocol:mainfrom
JosephDoUrden:fix/memory-vitest-false-positive
Open

fix(memory): resolve Vitest false positive for expected rejection#3893
JosephDoUrden wants to merge 1 commit intomodelcontextprotocol:mainfrom
JosephDoUrden:fix/memory-vitest-false-positive

Conversation

@JosephDoUrden
Copy link
Copy Markdown

Summary

  • Fix Vitest reporting a false-positive "Unhandled Errors" warning for the "should throw error for non-existent entity" test in the memory server
  • Wrap the addObservations call in an async closure before asserting with .rejects.toThrow(), so Vitest's global unhandled rejection handler does not capture the expected error before the assertion intercepts it

Fixes #3073

Root cause

addObservations is an async function that throws synchronously inside a .map() callback. When passed directly to expect().rejects.toThrow(), the resulting Promise rejection is seen by Vitest's global rejection handler before the .rejects wrapper can intercept it. Wrapping in async () => { await ... } ensures the rejection is properly scoped.

Changes

  • src/memory/__tests__/knowledge-graph.test.ts: Wrap the assertion in an async closure

Test plan

  • All 45 memory server tests pass (npx vitest run)
  • No "Unhandled Errors" warning in test output

AI Disclosure

AI assistance (Claude) was used for issue research. The implementation was written and reviewed by the author.

Wrap the addObservations call in an async closure before asserting with
.rejects.toThrow(). This prevents Vitest's global unhandled rejection
handler from capturing the expected error before the assertion can
intercept it, eliminating the false-positive "Unhandled Errors" warning
that was failing CI.

Fixes modelcontextprotocol#3073
Copy link
Copy Markdown
Member

@olaservo olaservo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct fix for the well-known Vitest/Jest async rejection race. Wrapping in an async closure ensures the rejection is scoped to the expect chain. Clean and minimal.


This review was assisted by Claude Code.

@cliffhall cliffhall added bug Something isn't working server-memory Reference implementation for the Memory MCP server - src/memory labels Apr 20, 2026
@cliffhall
Copy link
Copy Markdown
Member

@claude review

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Apr 21, 2026

Claude finished @cliffhall's task in 1m 13s —— View job


Code Review

  • Read the changed file and diff
  • Analyze the fix correctness
  • Check for related issues or improvements
  • Post review feedback

Verdict: LGTM — correct, minimal fix.

What changed

-      await expect(
-        manager.addObservations([
+      await expect(async () => {
+        await manager.addObservations([
           { entityName: 'NonExistent', contents: ['some observation'] },
-        ])
-      ).rejects.toThrow('Entity with name NonExistent not found');
+        ]);
+      }).rejects.toThrow('Entity with name NonExistent not found');

src/memory/__tests__/knowledge-graph.test.ts, line 149–153

Root cause analysis

Looking at the implementation in src/memory/index.ts (lines 139–152), addObservations is declared async but the actual throw happens synchronously inside the .map() callback:

async addObservations(...): Promise<...> {
  const graph = await this.loadGraph();
  const results = observations.map(o => {
    const entity = graph.entities.find(e => e.name === o.entityName);
    if (!entity) {
      throw new Error(`Entity with name ${o.entityName} not found`); // sync throw
    }
    ...
  });
  ...
}

A synchronous throw inside an async function body is caught by the async machinery and converted to a rejected Promise — but it resolves on the very next microtask tick. When the original code passed the resulting Promise directly to expect(promise).rejects, there was a race: Vitest's global unhandled-rejection handler could capture the rejection before .rejects had a chance to attach its own handler.

Wrapping in async () => { await ... } hands Vitest a function rather than an already-rejected Promise. Vitest calls the function and attaches its rejection interceptor first, so the race no longer exists. This is the recommended pattern in the Vitest docs.

Review notes

  • Correctness: The fix is correct and eliminates the race condition as described.
  • Scope: Only the one affected test is changed; no other tests in the file pass Promises directly to .rejects, so no other changes are needed.
  • Minimality: 4 lines in, 4 lines out — no unnecessary churn.
  • PR description accuracy: The description correctly identifies the root cause (sync throw inside async function passed directly as a Promise).
  • No production code changes: The fix is entirely in the test; the addObservations implementation is untouched (appropriate, since the implementation behaviour is correct).

This is a clean, well-understood fix for a known Vitest/Jest async-rejection timing issue. Already approved by @olaservo. Ready to merge.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working server-memory Reference implementation for the Memory MCP server - src/memory

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Vitest False Positive in PR Added in-memory locks for the process, fix for Bug #2579 temporary measure. #3060

3 participants